Skip to content

Fix UI beachball when a review session spins up#405

Merged
dgershman merged 1 commit into
mainfrom
feature/crow-404-fix-beachball-on-review
Jun 2, 2026
Merged

Fix UI beachball when a review session spins up#405
dgershman merged 1 commit into
mainfrom
feature/crow-404-fix-beachball-on-review

Conversation

@dgershman
Copy link
Copy Markdown
Collaborator

Summary

  • Review-session kickoff was synchronously blocking the main actor on every gh/git step, producing the macOS spinning beachball for the duration of the worst step (often a full gh repo clone).
  • SessionService is @MainActor, so even though createReviewSession() is async, the old shell() helper used Process.waitUntilExit() — which blocked the main thread instead of yielding.
  • Two surgical fixes restore UI responsiveness throughout review kickoff.

Fix 1 — shell() truly yields

Replaced process.waitUntilExit() with withCheckedThrowingContinuation + process.terminationHandler, and marked shell() (and a new runShellAsync static) nonisolated. Now await shell(...) actually suspends and frees the main actor while the subprocess runs in the background.

Fix 2 — createReviewSession() runs git/file-prep off-main

Extracted the gh pr view / gh repo clone / git fetch/checkout/pull / prompt-write / skill-copy block into a new nonisolated static prepareReviewClone(...) helper invoked via Task.detached(priority: .userInitiated). The main-actor tail only handles the cheap state mutations and the prepareTerminal call (still bounded by the existing 2s tmux watchdog).

The kickoff serialization queue in AppDelegate.enqueueReviewKickoff is unchanged — multiple PRs still process in order; the awaits just no longer wedge the UI.

Closes #404

Test plan

  • swift build --arch arm64 clean
  • arch -arm64 swift test --arch arm64 — 145 tests pass
  • Trigger a review on a PR whose repo is NOT yet cloned (worst-case full-clone path). Click around the sidebar / switch sessions during kickoff — no beachball, no input lag.
  • Trigger a review on a PR whose repo IS already cloned (fast path). Sidebar stays responsive; new review session appears when ready.
  • Enable autoReviewWatcherEnabled, push a commit to an open PR, observe the watcher fire a kickoff — UI stays responsive.
  • Burst test: enqueue 3+ PRs in quick succession via enqueueReviewKickoff. UI stays responsive; sessions appear in deterministic order (queue serialization preserved).

🐦‍⬛ Generated with Claude Code, orchestrated by Crow

Review kickoff was running gh/git/clone calls synchronously on the main
actor, freezing the app for as long as the worst gh/git step took.
SessionService is @mainactor, and the old shell() helper used
process.waitUntilExit() — so each await shell(...) blocked the main
thread instead of yielding.

Two surgical changes:

1. shell() now uses withCheckedThrowingContinuation +
   process.terminationHandler and is marked nonisolated, so await
   shell(...) truly suspends the calling task while the subprocess runs
   in the background.

2. createReviewSession()'s gh/git/file-write block is extracted into a
   nonisolated static prepareReviewClone() helper that runs inside a
   Task.detached. The main-actor tail only handles the lightweight
   appState mutation, store.mutate, and prepareTerminal (still capped
   at 2s by the existing tmux watchdog).

JSONStore.mutate is unchanged — its NSLock is already released before
the disk write (#304). The kickoff serialization queue in
AppDelegate.enqueueReviewKickoff is unchanged; it still awaits each
createReviewSession in order.

🐦‍⬛ Generated with Claude Code, orchestrated by Crow

Co-Authored-By: Claude <noreply@anthropic.com>
Crow-Session: CF6BE480-B904-41F8-A41B-0825804E67F8
@dgershman dgershman requested a review from dhilgaertner as a code owner June 2, 2026 18:04
@dgershman dgershman added the crow:merge Crow auto-merge on green label Jun 2, 2026
Copy link
Copy Markdown
Contributor

@dhilgaertner dhilgaertner left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code & Security Review

Critical Issues

None.

Summary

The fix correctly addresses the main-actor beachball (#404) with two well-scoped changes:

  1. shell() truly yields — replacing waitUntilExit() with withCheckedThrowingContinuation + terminationHandler, and marking it nonisolated. shell() uses no instance state, so dropping main-actor isolation is safe; all existing callers (worktree list, remote get-url, gh issue view, gh pr view, repo clone) still await it correctly.
  2. prepareReviewClone off-main — the gh/git/file-prep block moves into a nonisolated static helper run via Task.detached(.userInitiated), leaving only cheap state mutations on the main actor. env is captured as ShellEnvironment (public final class … : Sendable), and Scaffolder.bundled* / buildReviewPrompt are plain non-isolated statics, so the off-main hop is concurrency-clean.

Security Review

Strengths:

  • No new external input surface; PR URL parsing and command args are unchanged from the prior implementation. No shell string interpolation — args are passed as an array to /usr/bin/env, so no command-injection vector is introduced.
  • Error-message construction now prefers stderr and falls back to stdout; neither is surfaced to users beyond NSLog, consistent with prior behavior.

Concerns: None.

Code Quality

  • Continuation safety is correct. terminationHandler is installed before process.run(), so the success path resumes exactly once. The catch on launch failure defensively clears terminationHandler = nil before resuming, preventing a double-resume — a nice touch that matches the documented macOS launch-failure edge case.
  • Behavioral parity preserved. PR-metadata fetch failure aborts kickoff (throw → caught → return nil + NSLog), matching the old guard; git fetch/checkout/pull and clone errors remain tolerated via try?. Kickoff serialization in AppDelegate.enqueueReviewKickoff is untouched, so PR ordering is preserved.

Findings

Green — pipe read-after-exit comment is misleading (Sources/Crow/App/SessionService.swift:830-833, 845-853). The doc comment claims reads-after-exit means "no deadlock from a full pipe blocking the child." That reasoning is backwards: draining readDataToEndOfFile only after terminationHandler fires is precisely the pattern that can deadlock if a child writes more than the ~64KB pipe buffer to stdout/stderr and then blocks on write(), never exiting — so the handler never fires and the continuation never resumes. This is not a regression (the old waitUntilExit() path had the identical property) and is harmless for the current callers, whose output is small (gh/git to a non-tty emit minimal progress). But since runShellAsync is now a reusable static helper, consider either correcting the comment or draining the pipes concurrently (e.g. readabilityHandler/background reads) before reusing it for high-volume commands like gh pr diff on a large PR.

Green — no direct unit coverage for runShellAsync. Subprocess runners are awkward to unit-test, and the existing 145-test suite exercises the surrounding paths, so this is acceptable; a small test against a trivial /usr/bin/env true/false would still be cheap insurance for the exactly-once-resume contract.

Note: I could not build locally — swift build fails on a missing Frameworks/GhosttyKit.xcframework binary artifact, which is an environmental/checkout issue unrelated to this diff. The change is self-contained to one file; the PR reports a clean swift build --arch arm64 and 145 passing tests.

Summary Table

Color Meaning Verdict effect
Red Must fix Request changes
Yellow Should fix Request changes
Green Consider Approve allowed

Recommendation: Approve — driven by [0 Red, 0 Yellow, 2 Green] findings. Correct, concurrency-clean fix that resolves the beachball without altering kickoff ordering or behavior on error paths.


🐦‍⬛ Reviewed by Crow via Claude Code

@dgershman dgershman merged commit d87599a into main Jun 2, 2026
3 checks passed
@dgershman dgershman deleted the feature/crow-404-fix-beachball-on-review branch June 2, 2026 18:11
dgershman added a commit that referenced this pull request Jun 2, 2026
## Summary

- Stop trusting the lagging `ReviewRequest.reviewSessionID` cross-ref
and consult `appState.sessions` directly via a new
`AppState.existingReviewSession(forPRURL:)` helper.
- Layer the check at three sites: auto-review watcher guard,
`createReviewSession` early-return backstop, and both Start Review
buttons in `ReviewBoardView`.
- Extract the PR-URL parser into `Session.parseReviewPR(url:)` so all
sites share one parser.

## Why

After #405 moved `createReviewSession`'s git work into `Task.detached`,
the main actor stays responsive during the ~10s clone window. The
IssueTracker watcher can now tick during that window — and because
`request.reviewSessionID` is populated lazily on the *next* refresh, the
watcher's existing guard sees stale `nil`, enqueues a second kickoff,
and `createReviewSession` (with no dedup of its own) creates a duplicate
session.

The same lag was also why the per-row Start Review button stayed
clickable for several seconds after a session had been created.

## Test plan

- [x] `arch -arm64 swift test --package-path Packages/CrowCore` — 205
tests pass (includes the new `existingReviewSession` and `parseReviewPR`
test files)
- [x] `arch -arm64 swift test` (root + every package) — all suites pass
except a pre-existing flaky
`retryReadinessEmitsTimedOutWhenSentinelMissing` tmux timing test in
CrowTerminal that's unrelated to this change
- [x] `swift build --arch arm64` clean
- [ ] Manual smoke (reviewer): with `autoReviewRepos` configured, open a
`crow:auto` PR, confirm exactly one `review-<repo>-<num>` session
appears even when the IssueTracker ticks multiple times during the
clone; confirm the Start Review button flips to "Go to Session" on the
same tick the session appears; force-push the PR to confirm the
SHA-advanced re-kick path still works.

Closes #406

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crow:auto crow:merge Crow auto-merge on green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crow UI locks up with macOS spinning beachball when a review spins up

2 participants